qwen4_exp: load the block-FP8 dense projections (fixes modelopt MIXED_PRECISION checkpoints) - #392
Conversation
|
Ran this on 2 x RTX 6000 Ada (sm_89, 48 GB each, PCIe 4 x16, no NVLink) with the lovedheart NVFP4-FP8 (modelopt MIXED_PRECISION) checkpoint, offload backend, KV pool 262,144 tokens, pinned PLE, 8 running requests, one card (TP=1) unless stated. Tree: main af71ba4 + TP (#385) + load-time per-tensor FP8 (#389) + this PR cherry-picked. Same harness as my other PR feedback: single-stream = median of three 256-token generations, aggregate = eight concurrent 256-token requests, TTFT on a ~1k-token prompt, residency = expert slots / 24,576, probe = the same 8-question greedy smoke test (thinking off).
So the win reproduces on Ada at TP=1, and it is the residency effect you describe: +3.9 points of experts on the card. In isolation the Triton block-FP8 GEMV is slower than bf16 cuBLAS on this card (3.78 vs 3.20 ms per 48-layer decode step at M=1, the micro-benchmark in #389), but the freed 2.5 GiB buys more than that costs. Per-tensor At TP=2 (both cards, 16 running, vision tower loaded) the block-FP8 linears have no parallel variant, so I carry this PR on my deploy branch with a precedence rule: Two notes on the diff from that merge: (1) routing the bf16 fallback of |
|
Thanks for taking both points. The One thing to flag on point 1, not a problem with this PR as it stands but a hazard when it meets #385. The new bf16 branch in self.o_proj = LinearReplicated(self.qo_attn_dim, config.hidden_size, has_bias=False)That is exactly what main does today, so this PR is correct on its own. But it is also the precise path a rank takes under TP>1 — the one your commit message calls out — and there On my deploy branch the same self.qkv_proj = LinearColParallelMerged(
config.hidden_size, qkv_sizes, has_bias=False, local_output_sizes=self._qkv_split
)
self.o_proj = LinearOProj(self.qo_attn_dim, config.hidden_size, has_bias=False)
I am not asking you to change anything here; #385 is mine and the merge is my problem. Flagging it so whichever of the two lands second does not silently keep the Also worth knowing, since your commit message mentions the two cannot be tested together on your box: the combination is running here. 🤖 Generated with Claude Code |
|
Thanks, that's a fair flag and cheaper to fix now than to remember at merge time. Swapped in 2b2a96e: the bf16 branch now builds On the Verified at TP=1 on the Blackwell box: the four I'll take you up on the TP=2 run. The case I can't exercise is exactly this one, the bf16 fallback under TP, so: deploy/chatdnp with this commit, |
FlashML-org#426 taught parse_config to read the checkpoint's QuantConfig, so a modelopt MIXED_PRECISION build that declares its dense attention and GDN projections FP8_PB_WO now resolves to a block-FP8 scheme, and gdn.py builds the in_proj_qkvz + in_proj_ba split for it. The reader was never taught the other half. It still fuses all four in_proj parts into one buffer, so on those checkpoints it cats fp8 qkv|z with bf16 b|a and dies in torch.cat before the model sees anything, and no weight_scale_inv ever reaches the buffers Fp8BlockLinearMethod declares. The four-way fusion cannot survive block-FP8 in any case: b|a are num_v_heads rows wide, and create_weights requires every output size to be a whole number of 128-row blocks. The split is a requirement, not an optimization. So the reader now asks which fused attention groups are block-FP8 and emits in_proj_qkvz | in_proj_ba (and q|k|v -> qkv_proj) for those, fusing each group's weight_scale_inv on the same axis as its weight. Every fp8 part is a whole number of 128-row blocks (10240|6144 for qkv|z, 12288|512|512 for q|k|v), so the per-block scales concatenate exactly alongside the rows they describe. It asks through checkpoint_quant_config -- the same QuantConfig, built from the same ModelSpec name map the engine hands the model, queried with the same attribute prefixes gdn.py and attention.py use. That is what keeps the buffers this reader emits from disagreeing with the modules the model built. It matters beyond tidiness: the block-FP8 linears have no tensor-parallel variant, so a rank that downgrades has to downgrade on both sides at once. A checkpoint that stores block-FP8 dense WITHOUT declaring it per module gets no scheme, so the model builds plain bf16 linears for it. Those weights keep the existing dequantize-at-load path, and their weight_scale_inv is dropped rather than emitted into a buffer that does not exist.
qwen4_exp is the only family that builds its attention o_proj as LinearReplicated; llama, gpt_oss and minimax_m2 all use LinearOProj. That is correct at TP=1 and wrong under TP>1 three ways at once: qkv_proj is column-parallel, so a rank's attention output is its local head slice rather than the full qo_attn_dim, o_proj therefore has to take the sharded input dim, and the partial sums need an all-reduce. LinearReplicated keeps the full [hidden, qo_attn_dim] weight, expects the unsharded input and reduces nothing. It also fails quietly: a missing all-reduce leaves each rank holding a partial sum that still decodes to fluent-looking text. LinearOProj degenerates to exactly LinearReplicated at TP=1 -- div_even(x, 1) == x, and the all-reduce is skipped when tp_size == 1 -- so this is a no-op for main as it stands and only changes what FlashML-org#385 finds when the two meet. It adds no constraint from calling get_tp_info() in __init__ either, since the same constructor already reaches it two lines up through LinearColParallelMerged. The comment above the branch now describes what is built. Raised by @gdevenyi against the earlier form of this work in FlashML-org#392.
2b2a96e to
295d0d6
Compare
Rewritten against current
main. #418 and #426 landed the model half of this while the PR sat, so what is left is the reader half plus one unrelated TP fix — 2 files instead of 5, and no overlap with what is already merged.main cannot load these checkpoints today
gdn.pyalready builds thein_proj_qkvz+in_proj_basplit when the GDN input projection resolves to a block-FP8 scheme, and after #426 it does resolve for a modeloptMIXED_PRECISIONbuild. The reader was never taught the other half: it still fuses all fourin_projparts into one buffer. Onlovedheart/Qwen3.8-Flash-Next-NVFP4-FP8(204quantized_layers: 48NVFP4experts, 156FP8_PB_WOdense) that is:fa814ab, driving main's own_rename/_try_fuseover the real shards for layer 0. Nothing reaches the model, and noweight_scale_invreaches the buffersFp8BlockLinearMethoddeclares.The split is required, not an optimization
Fp8BlockLinearMethod.create_weightsraises unless every output size is a whole number of 128-row blocks. The four-way fusion'sb|ahalf isnum_v_headsrows — 96 on this model. A block-FP8 four-wayin_projcannot be built at all, which is whygdn.pysplits it, matching sglang and vLLM.What this does
The reader asks which fused attention groups are block-FP8 and, for those, emits
in_proj_qkvz|in_proj_baand fuses each fp8 group'sweight_scale_invon the same axis as itsweight. Every fp8 part is a whole number of 128-row blocks (10240|6144forqkv|z,12288|512|512forq|k|v), so the per-block scales concatenate exactly alongside the rows they describe.It asks through
checkpoint_quant_config— the sameQuantConfig, built from the sameModelSpecname map the engine hands the model, queried with the same attribute prefixesgdn.pyandattention.pyuse. That is what keeps the buffers the reader emits from disagreeing with the modules the model built. It matters beyond tidiness: the block-FP8 linears have no tensor-parallel variant, so a rank that downgrades has to downgrade on both sides at once.A checkpoint that stores block-FP8 dense without declaring it per module gets no scheme, so the model builds bf16 linears for it. Those keep the existing dequantize-at-load path, and their
weight_scale_invis dropped rather than emitted into a buffer that does not exist.Verified
Against the real checkpoint on one RTX PRO 4000 Blackwell, driving the reader over the actual shards:
layers.0.linear_attn.in_proj_qkvz.weightfloat8_e4m3fn(16384, 2560)layers.0.linear_attn.in_proj_qkvz.weight_scale_invfloat32(128, 20)layers.0.linear_attn.in_proj_ba.weightbfloat16(96, 2560)layers.11.self_attn.qkv_proj.weightfloat8_e4m3fn(13312, 2560)layers.11.self_attn.qkv_proj.weight_scale_invfloat32(104, 20)layers.0.linear_attn.out_proj.weightfloat8_e4m3fn(2560, 6144)layers.0.linear_attn.out_proj.weight_scale_invfloat32(20, 48)16384/128 == 128and2560/128 == 20scale rows/cols;13312/128 == 104. No four-wayin_projis emitted, no scale is emitted for the bf16in_proj_ba, no fusion is left buffered, and the shared expert and HC projections stay bf16 (they are on the modeloptignorelist). The declaration probe answersTrueforin_proj_qkv/in_proj_z/out_proj/q_proj/o_projandFalseforin_proj_b/in_proj_a/shared_expert.gate_proj/input_mix_weight_down.tests/models/qwen4_exp/: 42 failed, 54 passed, 51 skipped on this branch and the identical failure set at thefa814abmerge-base — every one a CUDA OOM or a missing optional import on a box whose single 24 GB card is 23.6 GB occupied serving a model. No regressions.Deliberately breaking the scale fusion, and separately restoring the four-way table, both turn the checks above red, so they are not passing vacuously.
Decode numbers from the earlier form of this PR still describe the benefit of serving these natively rather than dequantizing — 27.43 → 34.20 tok/s here, and @gdevenyi independently measured 58.4 → 69.9 (+19.7%) at TP=1 on 2 x RTX 6000 Ada with residency 36.6% → 40.5%. They are no longer a before/after against
main, becausemainhas no dequantize path for a declared checkpoint; it raises.Second commit is independent
qwen4_expis the only family building its attentiono_projasLinearReplicated; llama, gpt_oss and minimax_m2 all useLinearOProj. That is correct at TP=1 and silently wrong under TP>1 —qkv_projis column-parallel, soo_projneeds the sharded input dim and an all-reduce, and a missing reduction still decodes to fluent text.LinearOProjdegenerates to exactlyLinearReplicatedat TP=1 (div_even(x, 1) == x, all-reduce skipped), so it is a no-op today and correct when #385 lands. Raised by @gdevenyi against the earlier form of this PR; split out so it can be dropped without touching the reader fix.Gone from the previous version
The config detection, the
attn_quant/dense_quantplumbing, thequant_linear.pyfactories and theattention.pyquant wiring are all superseded by #418 and #426 and are not in this diff.